feat(auth): optional auth user cache for EloquentUserProvider#371
feat(auth): optional auth user cache for EloquentUserProvider#371binaryfire merged 11 commits into0.4from
EloquentUserProvider#371Conversation
Adds an opt-in, per-provider cache for retrieveById() lookups — the hot
path on every authenticated request in a Swoole worker, where one DB
query per request per worker adds up fast at scale.
Public surface:
- enableCache(?string $storeName, int $ttl, ?string $prefix): static
- isCacheEnabled(): bool
- clearUserCache(mixed $identifier): void
- resolveUserCacheKeyUsing(Closure): void (static, global)
- flushState(): void (static, test isolation)
Key design points:
- Accepts a store NAME (nullable = default store), not a pre-resolved
repository, so the invalidation registry can re-resolve by name via
Container::getInstance()->make('cache') without holding provider refs.
- Validates the resolved Store against a whitelist (RedisStore,
DatabaseStore, FileStore, SwooleStore, StackStore) via instanceof
BEFORE any instance state is mutated. Rejected stores leave the
provider uncached and don't register descriptors or listeners.
- Cache keys are {prefix}:{model-FQCN}:{identifier}, with the FQCN
memoized once in enableCache() so the hot path doesn't recompute it.
The FQCN segment is always present so providers using different
models never collide — even when two models share a basename across
namespaces.
- Global static resolveUserCacheKeyUsing() callback shapes the
identifier segment, evaluated at call time so tenant-like per-request
context is current (a config-file closure would capture boot-time).
- Model saved/deleted listeners invalidate via a descriptor registry of
plain-data arrays keyed by deterministic hash — no provider refs, so
AuthManager::forgetGuards() / re-resolve cycles don't leak providers,
and duplicate configs collapse on insert.
- Listener registration is guarded by Model::getEventDispatcher() being
non-null, and the cacheEventsRegistered flag is only set after a
successful attach, so if the dispatcher isn't set yet at first
enableCache() time the next call retries.
- Null-sentinel caching of missing users prevents repeated DB queries
for nonexistent ids.
- updateRememberToken/rehashPasswordIfRequired rely on the listener
firing on the save they already do — no explicit clear inside them.
…he() createEloquentProvider() now calls $provider->enableCache() when the cache config block has enabled=true. Passes the store NAME (nullable; null = default store), TTL, and prefix straight through — the provider re-resolves the store by name on invalidation, so no pre-resolved repository is held at this layer. No change to the database provider path.
Exposes a guard-scoped wrapper around EloquentUserProvider::clearUserCache() for apps that need to invalidate cached user entries from write paths that bypass Eloquent model events — pivot-table writes for roles and permissions, mass update() / raw DB queries, queue jobs, external processes, etc. Signature: clearUserCache(mixed $identifier, ?string $guard = null): void Behaviour: - Omitted $guard uses the default guard. - Resolves the guard's existing provider via getProvider() instead of constructing a throwaway one. - No-op when the guard doesn't expose getProvider() (custom guards that don't use GuardHelpers — checked via method_exists to avoid hitting AuthManager::__call and triggering BadMethodCallException at runtime). - No-op when the provider isn't an EloquentUserProvider or caching is disabled. The provider's key resolver (if set) runs, so in a tenant-aware setup this clears the entry for the current tenant context.
Adds the @method static docblock entry for Auth::clearUserCache() next to resolveUsersUsing so IDEs autocomplete and phpstan understands the call through the facade.
Adds the per-provider 'cache' block to the default 'users' provider:
'cache' => [
'enabled' => env('AUTH_USERS_CACHE_ENABLED', false),
'store' => env('AUTH_USERS_CACHE_STORE'),
'ttl' => env('AUTH_USERS_CACHE_TTL', 300),
'prefix' => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'),
],
Disabled by default; a single env flip turns it on. The block is
preceded by a prescriptive docblock listing supported stores (redis,
database, file, swoole, stack), rejected stores (array, null, session,
failover), cross-node behaviour (fully-shared vs node-local vs
stack microcaching), and the recommended high-scale topology
(stack = [swoole L1 → redis L2]). Full rationale lives in the auth
caching documentation; the config comment stays short and prescriptive.
Testbench's auth.providers.users wholly replaces the framework's users entry during config merge (the merge is one level deep on 'providers', not recursive), so any key we want present at runtime needs to be repeated here — including the new 'cache' block. Same shape and same docblock as the framework config. Disabled by default, so this change is purely discoverability/consistency for testbench-bootstrapped test suites and skeletons cloned from testbench.
Adds a new 'User Lookup Cache' section to the auth package README covering: - Purpose: opt-in cross-request cache for retrieveById() to eliminate one DB query per authenticated request under Swoole. - Config examples: minimum Redis setup and the high-scale stack (swoole L1 + redis L2) setup. - Microcaching rationale: what it is, why it matters at scale, and the concrete wins (p99 latency, Redis tier sizing, bandwidth, resilience during brief Redis outages). - Invalidation model: four layers (provider writes via save(), model events, manual Auth::clearUserCache, TTL), with explicit notes on same-node propagation via Swoole Table and bounded cross-node staleness when using a stack with a node-local L1. - Manual invalidation API: full parameter semantics, how the model is chosen from the guard, multi-guard / multi-model behaviour (one-provider-shared-by-many-guards vs different-model-per-guard), tenant-resolver interaction, and no-op conditions. - TTL guidance per scenario. - Store selection guide mirroring the config docblock. - Tenant-aware cache keys with the resolveUserCacheKeyUsing() pattern and why it has to be a static callback, not a config closure. - Gotchas: withQuery caching effect, mass-update event bypass, outer- only stack validation. - Threat-model notes for high-security providers. Marked with a @todo so the whole section can be lifted into the 0.4 documentation site once it lands — the README is just the interim home.
…scriber Resets the provider's static state (the cache key resolver, descriptor registry, and listener-registered flag) between tests so cache-related tests are isolated from each other. Placed alphabetically between AuthenticationException::flushState() and Middleware\Authenticate::flushState().
New dedicated test class kept separate from AuthEloquentUserProviderTest
so the base-provider coverage stays focused and the caching feature
gets its own home.
Covers, with Mockery doubles only (no container, no DB, no dispatcher):
- Cache disabled: retrieveById falls through to the DB path with no
cache interaction.
- Basic operation: miss → DB → put, hit → no DB, missing-user null
sentinel stored on miss, null returned on sentinel hit.
- retrieveByCredentials and retrieveByToken never touch the cache.
- Key format: default {prefix}:{FQCN}:{id}, null/'' prefix normalizes
to the feature default, custom resolver used for the identifier
segment, custom resolver receives the raw identifier, FQCN always
present in the key even with a custom resolver.
- Supported-store whitelist (data provider over Redis/Database/File/
Swoole/Stack): enableCache accepts each.
- Unsupported-store rejection (data provider over Array/Null/Session/
Failover): enableCache throws InvalidArgumentException.
- Validation-failure ordering: after a rejected store, the provider
is still disabled, the descriptor registry is empty, the
events-registered flag is untouched, and retrieveById falls through
to the DB path — confirms 'validate before mutate'.
- Manual clearUserCache: forgets the right key, respects the custom
resolver, no-op when caching is disabled.
- flushState resets the resolver, descriptor registry, and
events-registered flag.
Uses a stub Model subclass (EloquentCacheProviderUserStub) because the
registerCacheInvalidationEvents() path touches ::getEventDispatcher()
on the model class, which needs a real class to resolve.
New Testbench-based integration suite covering the paths that need a real event dispatcher and a real Model class (things the unit suite can't exercise). The cache repository itself is still a Mockery double so forget() calls are verifiable — what matters here is the real saved/deleted event firing, not the cache backend. Covers: - Cache is cleared on user save. - Cache is cleared on user delete. - Identical (store, prefix, modelSegment) configs dedupe in the descriptor registry. - Two distinct (store, prefix) configs for the same model each get invalidated on a single save — the listener attaches once, iterates both descriptors, fires exactly one forget() per descriptor (guards against accidental double-attach). - updateRememberToken and rehashPasswordIfRequired clear the cache via the saved event — no explicit clear inside the methods. - enableCache skips listener registration when the model has no dispatcher yet, leaves the events-registered flag unset so a later enableCache() call retries, but still registers the descriptor. - withQuery compatibility: the withQuery callback runs only on the cache-miss fetch; subsequent calls hit the cache. Uses Hypervel\Foundation\Auth\User (via #[WithMigration] + RefreshDatabase) so the model is a real Eloquent User with dispatch set up by the framework boot.
Adds four tests exercising the clearUserCache() convenience method end to end through the manager + provider + cache stack (cache manager stubbed via Container::instance, repository + store as Mockery doubles): - Custom guard without getProvider() — method_exists guard kicks in, call is a no-op, no BadMethodCallException from __call forwarding. - Specified guard uses that guard's provider/model — clearing user 42 on the 'admin' guard forgets AuthManagerCacheAdminStub:42 (not AuthManagerCacheUserStub:42), proving the guard determines the provider which determines the model. - Default guard + custom resolver — clearUserCache with no guard name uses the default guard's provider, and the key resolver runs so the forget key is the tenant-scoped one, not the raw id. - forgetGuards() + re-resolve does not accumulate provider descriptors: after forgetting the guard cache and re-resolving, the descriptor registry still has exactly one entry for the model. Guards against a potential leak where repeated resolve/forget cycles would grow the registry. Stub classes (AuthManagerCacheUserStub / AuthManagerCacheAdminStub) extend Foundation\Auth\User so getAuthIdentifier() and the model dispatcher behaviour are real.
| return new EloquentUserProvider($this->app['hash'], $config['model']); | ||
| $provider = new EloquentUserProvider($this->app['hash'], $config['model']); | ||
|
|
||
| if (! empty($config['cache']['enabled'])) { |
There was a problem hiding this comment.
@binaryfire it will be better to add some defense here?
if ($config['cache']['enabled'] ?? false)There was a problem hiding this comment.
Hi @albertcht. I think ! empty() and $config['cache']['enabled'] ?? false are functionally identical here? empty() handles missing keys without warnings the same way ?? does, and both produce identical if outcomes for every value enabled could be (bool, int, string, null, unset, etc.).
But I'm happy to change it to ?? false if you prefer. Let me know.
| $modelClass = $this->model; | ||
|
|
||
| // Insert or replace the descriptor — duplicate configs collapse. | ||
| $descriptorKey = md5( |
There was a problem hiding this comment.
@binaryfire Just to provide an idea: Could we optimize Hypervel by replacing md5 with xxh3 or xxh128, where we don't need to maintain compatibility with Laravel's hashing results?
There was a problem hiding this comment.
@albertcht Yeah that's a great idea. md5 is only used in a handful of places but it's definitely worth switching. I've added that to my list of changes to make for 0.4. Could we mark this as resolved? I'll make that change across the whole codebase separately.
Normally every authenticated request runs a SELECT to hydrate
Auth::user()viaEloquentUserProvider::retrieveById(). That becomes a major bottleneck in a high concurrency framework like Hypervel, and is the single biggest source of avoidable DB load on authenticated endpoints.This PR adds opt-in auth user caching. Default behaviour is unchanged (disabled), and one config change turns it on per provider. Everything else on the provider (
retrieveByCredentials,retrieveByToken,validateCredentials) stays uncached - credential and token lookups should always see fresh data.Enabling it
Minimum setup for a single Redis node:
High-scale recommended setup (swoole L1 plus redis L2):
The cache block sits inside each auth provider in
config/auth.php, so different providers can have different cache settings:New public surface
On
EloquentUserProvider:enableCache(?string $storeName, int $ttl = 300, ?string $prefix = 'auth_users'): staticisCacheEnabled(): boolclearUserCache(mixed $identifier): voidresolveUserCacheKeyUsing(Closure $callback): void(static, global)flushState(): void(static, test isolation)On
AuthManagerand theAuthfacade:Auth::clearUserCache(mixed $identifier, ?string $guard = null): voidNo new classes or driver types.
CreatesUserProviders::createEloquentProvider()callsenableCache()when the config saysenabled => true, otherwise the provider behaves exactly as before. TheEloquentUserProviderconstructor signature is unchanged, so existing code that instantiates it directly (including tests) keeps working.Supported cache stores
enableCache()validates the resolvedStoreagainst a whitelist before any instance state is mutated. A rejected store throwsInvalidArgumentExceptionon first guard resolution, leaving the provider in its uncached state.redisdatabasefileswoolestacksessionarraynullfailoverThe check uses
instanceof, so subclasses of supported stores are accepted too.Microcaching at scale
At the kind of RPS where this feature actually matters, a
stackwith Swoole as L1 and Redis as L2 is the recommended shape. Hot reads stay in the node-local Swoole Table for a few seconds; cold reads fall through to the shared Redis tier and auto-repopulate the L1 for the next hit. The effect eliminates most Redis round-trips for authenticated requests at high concurrency.The tradeoff is cross-node staleness.
StackStore::forget()only clears the L1 cache on the local node; it doesn't propagate to other nodes. With a node-local L1, other nodes keep serving their stale L1 entries until their own L1 TTL expires. That's usually not an issue since the window is usally small (eg. 3-5s).For apps that need strict global consistency, using plain
redisis still a major performance win.Cache key format
Default key:
{prefix}:{model-FQCN}:{identifier}, e.g.auth_users:App\Models\User:42. The fully qualified class name is always included so providers using different user models never collide, even when two models share a basename across namespaces (App\Models\UservsAdmin\Models\User). This matches the conventionPermissionManageralready uses for similar keys.For situations where the cache key needs to be set dynamically (eg. multi-tenant apps where the same user id resolves to different rows per tenant), register a global resolver in a service provider's
boot():This produces keys like
auth_users:App\Models\User:5:42(FQCN, tenant 5, user 42). The resolver is a static callback rather than a config so it's re-evaluated on every request.Invalidation model
Four layers:
save().updateRememberToken()andrehashPasswordIfRequired()both call$user->save(), which fires thesavedevent. Layer 2 handles the actual forget; neither method contains an explicit cache clear.savedanddeletedlisteners on the user model class. Any Eloquent write ($user->save(),$user->update(),$user->delete()) triggers invalidation.Auth::clearUserCache($id[, $guard]). For writes that bypass Eloquent events - pivot writes for roles and permissions, raw DB queries, massupdate(), queue jobs and external processes.Under the hood, the listener doesn't hold provider references. Each provider registers a plain-data descriptor (
storeName,prefix,modelSegment) under a deterministic hash; duplicate configs get collapsed to a single descriptor on insert. The listener iterates descriptors for the model class, re-resolves each store by name via the cache manager, then callsforget(). This keeps the feature safe againstAuthManager::forgetGuards()plus re-resolve cycles in long-running workers. Nothing gets leaked.Multi-guard / multi-model behaviour
Matters when you have a setup like
web => User,admin => Admin:web/api/sanctumall pointing atusers): oneAuth::clearUserCache($id)call clears the single shared keyspace. Calling for each guard is redundant.Auth::clearUserCache(42)with no guard argument only clears the default guard's model. Clearing a Admin needsAuth::clearUserCache(42, 'admin').Swoole safety
AuthManager::$guards. The caching-related instance properties ($cache,$cacheStoreName,$cacheTtl,$cachePrefix,$modelSegment) are set once inenableCache()and treated as read-only after, so there's no per-request mutable instance state.forgetGuards()plus re-resolve can't retain dead objects.Container::getInstance()->make('cache')->store($name), which is cached insideCacheManagerafter the first call. So subsequent invalidations are all hashmap lookups.Model::getEventDispatcher()being non-null, and the registered flag is only set after a successful attach. If the dispatcher isn't set at firstenableCache()time (unusual, but might happen in some edge cases), the next call retries.enableCache()time, before any state is mutated, so the feature can't be wired up against a user-scoped store (SessionStore), a useless store (Array / Null), or an ambiguously composite one (like Failover).Gotchas worth knowing
withQuery()callbacks run on the uncached fetch. Whatever relations get eager-loaded on that first call are the relations cached for everyone. This is usually what you want for auth. A good use of this is caching user permissions as well.User::query()->update([...]), rawDB::update(), pivotattach/detachwithout touching the User model. Developers will have to useAuth::clearUserCache()after those.stack. A stack built with an unsupported inner tier (e.g.[array, redis]) won't be caught by the check. That has been documented.Test coverage
tests/Auth/AuthEloquentUserProviderCacheTest.php- new unit suite, covering cache disabled path, basic operation (miss, hit, sentinel), key format (FQCN, prefix normalization, custom resolver), the whitelist (accept, reject, fail-without-mutate), manual invalidation, andflushState.tests/Integration/Auth/EloquentUserProviderCacheTest.php- new integration suite, real event dispatcher and DB. Tests covering model events firing real invalidation, descriptor dedup, multi-descriptor fan-out, listener registered only once,updateRememberTokenandrehashPasswordIfRequiredpaths, the dispatcher-missing case, andwithQuerycompatibility.tests/Auth/AuthManagerTest.php- 4 new tests coveringAuth::clearUserCache: custom guards withoutgetProvider, specified guard routes to the right provider and model, default guard uses the active key resolver,forgetGuards()plus re-resolve doesn't accumulate descriptors.Not included
clearAllUserCache(). Dynamic key resolvers (eg. tenant segments) can't be enumerated without cache tags, and not every supported store has tags. Per-user clearing is fine for almost every scenario. If a developer wants to do a bulk clear, they can clear the cache. Support for tags when using the Redis store could be a nice addition though.